/*
 *  Copyright 2008 Blandware (http://www.blandware.com)
 *
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */
package com.blandware.atleap.antext.core.generate;

import com.blandware.atleap.antext.core.exception.SpecificationParsingException;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Task;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Comparator;

/**
 * Task used to generate entity (model class) including Hibernate doclets from
 * specifications passed using parameters.
 * <p><a href="GenerateEntityTask.java.html"><i>View source</i></a></p>
 *
 * @author Roman Puchkovskiy <a href="mailto:roman.puchkovskiy@blandware.com">
 *         &lt;roman.puchkovskiy@blandware.com&gt;</a>
 * @version $Revision: 1.9 $ $Date: 2008/04/26 12:41:11 $
 */
public class GenerateEntityTask extends Task {

    private static final String MAIN_PACKAGE = "com.blandware.atleap";
    private static final String MODEL_PACKAGE = MAIN_PACKAGE + ".model";

    private static final String DEFAULT_ENTITY_SUBPACKAGE = "core";
    private static final String DEFAULT_TABLE_PREFIX = "al_";
    private static final String DEFAULT_PATH_PREFIX = "src/dao";
    private static final String DEFAULT_TEMPLATE = "metadata/templates/entity.vm";

    /**
     * Says that this entity is localizable. Defines that entity extends
     * Localizable and no equals()/hashCode() are generated.
     */
    public static final String TYPE_LOCALIZABLE = "localizable";
    /**
     * Says that this entity is page. Defines that entity extends Page and no
     * equals()/hashCode() are generated.
     */
    public static final String TYPE_PAGE = "page";

    /**
     * Name of entity to create (simple class name).
     */
    protected String entityName;
    /**
     * Subpackage (relatively to model package) in which entity will be created.
     */
    protected String entitySubpackage;
    /**
     * Type of entity. Defines superclass and some features.
     */
    protected String entityType;
    /**
     * String containing property specifications.
     */
    protected String propertySpecifications;
    /**
     * Comma-separated list of names of properties which will be used to
     * generate equals() and hashCode().
     */
    protected String equalsProperties;
    /**
     * Prefix to use when generating table names.
     */
    protected String tablePrefix;
    /**
     * Prefix to path of generated java source file.
     */
    protected String pathPrefix;
    /**
     * Content for 'author' tag.
     */
    protected String author;
    /**
     * Content for 'version' tag.
     */
    protected String version;
    /**
     * Path to header file.
     */
    protected String headerFile;
    /**
     * Template location.
     */
    protected String template;

    /**
     * Sets name of entity to generate.
     *
     * @param entityName    name of entity
     */
    public void setEntityName(String entityName) {
        this.entityName = entityName;
    }

    /**
     * Sets subpackage (relatively to model package) in which entity will be
     * created.
     *
     * @param entitySubpackage  package
     */
    public void setEntitySubpackage(String entitySubpackage) {
        this.entitySubpackage = entitySubpackage;
    }

    /**
     * Sets type of entity. Defines superclass and some features. If not set,
     * default type is used (i.e. base class is BaseObject).
     *
     * @param entityType    type to set
     */
    public void setEntityType(String entityType) {
        this.entityType = entityType;
    }

    /**
     * Sets property specifications string. It's a comma-separated list of
     * property specifications. Each property specification is a colon-separated
     * pair (property name, property type). Property type may be simple (just
     * name), or complex (name and a parameter in parentheses which is called
     * element).
     *
     * @param propertySpecifications    comma-separated list of property
     * specifications
     */
    public void setPropertySpecifications(String propertySpecifications) {
        this.propertySpecifications = propertySpecifications;
    }

    /**
     * Sets properties which are used to generate equals() and hashCode()
     * methods. If not set, all properties but ID and version properties are
     * used.
     *
     * @param equalsProperties  comma-separated list of property names; all
     * names must be known (i.e. they must be mentioned in property
     * specifications list)
     */
    public void setEqualsProperties(String equalsProperties) {
        this.equalsProperties = equalsProperties;
    }

    /**
     * Sets prefix which will be used when generating table names.
     *
     * @param tablePrefix   prefix
     */
    public void setTablePrefix(String tablePrefix) {
        this.tablePrefix = tablePrefix;
    }

    /**
     * Sets path prefix - it's path prefix which leads to directory which is
     * root of java packages hierarchy into which generated entity will be put.
     * Defaults to 'src/dao'.
     *
     * @param pathPrefix    prefix to set
     */
    public void setPathPrefix(String pathPrefix) {
        this.pathPrefix = pathPrefix;
    }

    /**
     * Sets contents for 'author' javadoc tag. If not set, this tag will not be
     * generated. 
     *
     * @param author    content to set
     */
    public void setAuthor(String author) {
        this.author = author;
    }

    /**
     * Sets contents for 'version' javadoc tag. If not set, this tag will not be
     * generated.
     *
     * @param version   content to set
     */
    public void setVersion(String version) {
        this.version = version;
    }

    /**
     * Sets path to file which will be included as header. If not set, nothing
     * will be included.
     *
     * @param headerFile    path to file
     */
    public void setHeaderFile(String headerFile) {
        this.headerFile = headerFile;
    }

    /**
     * Sets template location. Current implementation uses file-based resource
     * loader, so this is path in file-system.
     *
     * @param template  template location
     */
    public void setTemplate(String template) {
        this.template = template;
    }

    /**
     * Executes task.
     *
     * @throws BuildException   thrown if something is wrong
     */
    public void execute() throws BuildException {
        setDefaultValues();
        validateSettings();

        List parsedSpecifications;
        try {
            parsedSpecifications = parsePropertySpecifications(propertySpecifications);
        } catch (SpecificationParsingException e) {
            e.printStackTrace();
            throw new BuildException(e);
        }
        PropertySpecification idSpecification =
                findPropertySpecification(parsedSpecifications,
                        PropertyType.TYPE_ID);
        PropertySpecification versionSpecification =
                findPropertySpecification(parsedSpecifications,
                        PropertyType.TYPE_VERSION);
        if ((idSpecification != null || versionSpecification != null)
                && (TYPE_PAGE.equals(entityType) || TYPE_LOCALIZABLE.equals(entityType))) {
            throw new BuildException("It's illegal to specify ID or version property for entity which type is page or localizable");
        }

        moveBlobPropertiesToEnd(parsedSpecifications);

        Map model = new HashMap();

        String fullPackage = MODEL_PACKAGE + "." + entitySubpackage;
        String fullTablePrefix = tablePrefix + entitySubpackage + "_";
        if (author == null) {
            author = "";
        }
        if (version == null) {
            version = "";
        }

        String path = pathPrefix + '/' + packageToPath(fullPackage) + '/'
                + entityName + ".java";
        File file = new File(path);
        // just checking if such file exists; deferring changes to FS to moment
        // when template was successfully merged
        if (file.exists()) {
            throw new BuildException("File " + path + " already exists");
        }
        File dir = file.getParentFile();

        model.put("entityName", entityName);
        model.put("entityType", entityType);
        model.put("fullPackage", fullPackage);
        model.put("fullTablePrefix", fullTablePrefix);
        model.put("propertySpecifications", parsedSpecifications);
        model.put("equalsSpecifications", buildEqualsList(parsedSpecifications, equalsProperties));
        model.put("idSpecification", idSpecification);
        model.put("author", author);
        model.put("version", version);
        model.put("headerFile", headerFile);
        model.put("transformer", new StringTransformer());

        VelocityContext velocityContext = new VelocityContext(model);
        VelocityEngine velocityEngine = new VelocityEngine();
        StringWriter stringWriter = new StringWriter();
        try {
            velocityEngine.init();
            velocityEngine.mergeTemplate(template, velocityContext, stringWriter);
        } catch (Exception e) {
            e.printStackTrace();
            throw new BuildException(e);
        }
        String mergeResult = stringWriter.toString();

        // trying to save the result
        dir.mkdirs();
        try {
            Writer writer = new FileWriter(file);
            writer.write(mergeResult);
            writer.close();
        } catch (IOException e) {
            e.printStackTrace();
            throw new BuildException(e);
        }
    }

    /**
     * Sets default values for task properties which were not set and have
     * defaults.
     */
    protected void setDefaultValues() {
        if (isEmptyString(entitySubpackage)) {
            entitySubpackage = DEFAULT_ENTITY_SUBPACKAGE;
        }
        if (isEmptyString(tablePrefix)) {
            tablePrefix = DEFAULT_TABLE_PREFIX;
        }
        if (isEmptyString(pathPrefix)) {
            pathPrefix = DEFAULT_PATH_PREFIX;
        }
        if (isEmptyString(template)) {
            template = DEFAULT_TEMPLATE;
        }
    }

    /**
     * Validates settings (mainly presence of required task properties).
     */
    protected void validateSettings() {
        if (entityName == null || entityName.trim().length() == 0) {
            throw new BuildException("entityName cannot be empty");
        }
        if (entitySubpackage == null || entitySubpackage.trim().length() == 0) {
            throw new BuildException("entitySubpackage cannot be empty");
        }
        if (propertySpecifications == null
                || propertySpecifications.trim().length() == 0) {
            throw new BuildException("propertySpecifications cannot be empty");
        }
        if (tablePrefix == null) {
            throw new BuildException("tablePrefix cannot be null");
        }
        if (pathPrefix == null) {
            throw new BuildException("pathPrefix cannot be null");
        }
    }

    /**
     * Parses property specifications. Takes a comma-separated string and
     * returns list of specifications as objects.
     *
     * @param specs     string representing specifications
     * @return list of {@link PropertySpecification}s
     * @throws SpecificationParsingException    if specification is bad
     * @see #setPropertySpecifications(String)
     */
    protected List parsePropertySpecifications(String specs)
            throws SpecificationParsingException {
        List result = new ArrayList();
        // this set is used to avoid duplicate properties
        Set definedSpecNames = new HashSet();
        // flags are used to remember occurence of some property
        boolean metId = false;
        boolean metVersion = false;
        boolean metMultistring = false;
        boolean metFieldProperty = false;

        String[] fragments = specs.split(",");
        for (int i = 0; i < fragments.length; i++) {
            String fragment = fragments[i];
            PropertySpecification specification = parsePropertySpecification(fragment.trim());
            // cannot have duplicate properties
            if (definedSpecNames.contains(specification.getName())) {
                throw new SpecificationParsingException("Duplicate property specification:"
                        + specification.getName());
            }
            // cannot have more then one ID
            if (PropertyType.TYPE_ID.equals(specification.getType().getName())) {
                if (metId) {
                    throw new BuildException("At most one ID column is allowed");
                }
                metId = true;
            }
            // cannot have more then one version
            if (PropertyType.TYPE_VERSION.equals(specification.getType().getName())) {
                if (metVersion) {
                    throw new BuildException("At most one version column is allowed");
                }
                metVersion = true;
            }
            if (PropertyType.TYPE_MULTI_STRING.equals(specification.getType().getName())) {
                metMultistring = true;
            }
            if (specification.getType().isFieldBacked()) {
                metFieldProperty = true;
            }
            definedSpecNames.add(specification.getName());
            result.add(specification);
        }

        // multi-string requires ID...
        if (metMultistring && !metId
                && !(TYPE_LOCALIZABLE.equals(entityType) || TYPE_PAGE.equals(entityType))) {
            throw new BuildException("multistring properties are not allowed if no ID property is defined");
        }

        // field-backed requires localizable or page...
        if (metFieldProperty
                && !(TYPE_LOCALIZABLE.equals(entityType) || TYPE_PAGE.equals(entityType))) {
            throw new BuildException("field-backed properties are allowed only for pages or localizables");
        }

        return result;
    }

    /**
     * Parses property specification. More precisely, converts spec from string
     * to object.
     *
     * @param spec  specification to parse
     * @return parsed specification object
     * @throws SpecificationParsingException    if specification is bad
     * @see #setPropertySpecifications(String)
     */
    protected PropertySpecification parsePropertySpecification(String spec)
            throws SpecificationParsingException {
        String[] fragments = spec.split(":", 2);
        if (fragments.length != 2) {
            throw new SpecificationParsingException("Following is not valid property specification: "
                    + spec);
        }
        String name = fragments[0].trim();
        String type = fragments[1].trim();
        PropertyType propertyType = parsePropertyTypeSpecification(type);
        return new PropertySpecification(name, propertyType);
    }

    /**
     * Parses property type specification converting it from string form to
     * {@link PropertyType} instance.
     *
     * @param spec  type specification
     * @return parsed type
     * @throws SpecificationParsingException    if specification is bad
     */
    protected PropertyType parsePropertyTypeSpecification(String spec)
            throws SpecificationParsingException {
        return PropertyType.getType(spec);
    }

    /**
     * Builds a list of specifications of properties which will be used for
     * equals()/hashCode() generation.
     *
     * @param specs     list of all known property specifications
     * @param equalsProperties  comma-separated list of names of properties
     * which will constitute a list of equals()/hashCode() properties. If empty,
     * default list will be returned: all properties except ID and version.
     * @return list of property specifications which will be used for
     * equals()/hashCode() generation 
     */
    protected List buildEqualsList(List specs, String equalsProperties) {
        List result;
        if (isEmptyString(equalsProperties)) {
            // building default list
            result = buildDefaultEqualsList(specs);
        } else {
            // something was requested, build this list
            result = new ArrayList();
            String[] fragments = equalsProperties.split(",");
            Set equalsProps = new HashSet(Arrays.asList(fragments));
            Iterator it = specs.iterator();
            while (it.hasNext()) {
                PropertySpecification spec = (PropertySpecification) it.next();
                if (equalsProps.contains(spec.getName())) {
                    result.add(spec);
                    // removing to find out whether some equal properties are
                    // unknown
                    equalsProps.remove(spec.getName());
                }
            }
            if (!equalsProps.isEmpty()) {
                // some property in requested list is not known
                throw new BuildException("Unknown equals property: "
                        + equalsProps.iterator().next());
            }
        }
        return result;
    }

    /**
     * Builds a list of specifications of properties which will be used for
     * equals()/hashCode() generation containing all properties except ID and
     * version.
     *
     * @param specs     list of all known property specifications
     * @return list of property specifications which will be used for
     * equals()/hashCode() generation
     */
    protected List buildDefaultEqualsList(List specs) {
        List result = new ArrayList();
        Iterator it = specs.iterator();
        while (it.hasNext()) {
            PropertySpecification spec = (PropertySpecification) it.next();
            if (spec.getType().isDefaultEqualsCandidate()) {
                result.add(spec);
            }
        }
        return result;
    }

    /**
     * Returns true if string is null or contains only white-space.
     *
     * @param s     string to check
     * @return true if empty
     */
    private boolean isEmptyString(String s) {
        return s == null || s.trim().length() == 0;
    }

    /**
     * Converts package name to relative path (just replacing periods with
     * slashes.
     *
     * @param packageName   name of package
     * @return path
     */
    private String packageToPath(String packageName) {
        return packageName.replace('.', '/');
    }

    /**
     * Finds a property specification in list by type name.
     *
     * @param specs     list of property specifications
     * @param typeName  name of type by which to find property specification
     * @return first property specification with given type or null if nothing
     * found
     */
    private PropertySpecification findPropertySpecification(List specs,
                                                            String typeName) {
        PropertySpecification result = null;
        Iterator it = specs.iterator();
        while (it.hasNext()) {
            PropertySpecification spec = (PropertySpecification) it.next();
            if (typeName.equals(spec.getType().getName())) {
                result = spec;
                break;
            }
        }
        return result;
    }

    /**
     * Moves properties which type is BLOB to the end of list of specifications.
     *
     * @param specs     property specifications
     */
    private void moveBlobPropertiesToEnd(List specs) {
        Collections.sort(specs, new Comparator() {
            public int compare(Object o1, Object o2) {
                PropertySpecification spec1 = (PropertySpecification) o1;
                PropertySpecification spec2 = (PropertySpecification) o2;
                boolean blob1 = spec1.getType().getName().
                        equals(PropertyType.TYPE_BLOB);
                boolean blob2 = spec2.getType().getName().
                        equals(PropertyType.TYPE_BLOB);
                if (blob1 == blob2) {
                    return 0;
                } else {
                    return blob1 ? 1 : -1;
                }
            }
        });
    }
}
